;;; elbry-mode.el --- LBRY client for Emacs -*- lexical-binding: t; -*-

;; This file is not part of GNU Emacs.

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful, but
;; WITHOUT  ANY  WARRANTY;  without   even  the  implied  warranty  of
;; MERCHANTABILITY or FITNESS  FOR A PARTICULAR PURPOSE.   See the GNU
;; General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;; Version: 0.0.1
;; Keywords: lbry matching multimedia

;;; Commentary:

;; This package provide a major mode to interact with the LBRY data
;; network via Elfeed-like buffer.  The LBRY data network includes
;; videos, films, art, books, and many more.
;; To learn more please visit https://lbry.tech/

;;; Code:
(require 'json)
(require 'cl-lib)
;;;; Types

(cl-defstruct (elbry-entry (:constructor elbry-entry-create)
			  (:copier nil))
  "Information about a LBRY video."
  (id "" :read-only t)
  (elbry-url "" :read-only t)
  (release-time "" :read-only t)
  (file-date 0 :read-only t)
  (title     "" :read-only t)
  (stream-type "" :read-only t)
  (media-type "" :read-only t)
  (channel    "" :read-only t)
  (desc "" :read-only t)
  (tags [] :type vector :read-only t))

;;;; Faces

(defface elbry-channel
  (if (featurep 'elfeed)
      '((t :inherit elfeed-search-feed-face))
    '((((class color) (background light)) (:foreground "#003497"))
      (((class color) (background dark))  (:foreground "#92baff"))))
  "Face used for channel name in *ELBRY* buffer"
  :group 'elbry)

(defface elbry-date
  (if (featurep 'elfeed)
      '((t :inherit elfeed-search-date-face))
    '((((class color) (background light)) (:foreground "#00538b"))
      (((class color) (background dark))  (:foreground "#00d3d0"))))
  "Face used for date in *ELBRY* buffer"
  :group 'elbry)

(defface elbry-tags
  (if (featurep 'elfeed)
      '((t :inherit elfeed-search-tag-face))
    '((((class color) (background light)) (:foreground "#005a5f"))
      (((class color) (background dark))  (:foreground "#6ae4b9"))))
  "Face used for tags in *ELBRY* buffer"
  :group 'elbry)

(defface elbry-video-title
  (if (featurep 'elfeed)
      '((t :inherit elfeed-search-unread title-face))
    '((t :weight bold)))
  "Face used for title of claims with \"video\" stream type"
  :group 'elbry)

(defface elbry-image-title
  '((t :inherit outline-5))
  "Face used for title of claims with \"image\" stream type"
  :group 'elbry)
(defface elbry-audio-title
  '((t :inherit outline-7))
  "Face used for title of claims with \"audio\" stream type")
(defface elbry-binary-title
  '((t :inherit outline-3))
  "Face used for title of claims with \"binary\" stream type"
  :group 'elbry)

(defface elbry-document-title
  '((t :inherit outline-4))
  "Face used for title of claims with \"document\" stream type"
  :group 'elbry)


;;;; Customizable variables

(defgroup elbry nil
  "A LBRY application."
  :group 'comm
  :link '(url-link "https://gitlab.com/c1-g/elbry-mode-el"))

(defcustom elbry-order-by 'name
  "Order to sort the results of the search query. Default is descending order
to do an ascending order prepend ^ to the options"
  :type 'symbol
  :options '(choice (symbol :tag "Name" name)
                    (symbol :tag "Height" height)
                    (symbol :tag "Release_Time" release_time)
                    (symbol :tag "Publish_Time" publish_time)
                    (symbol :tag "Amount" amount)
                    (symbol :tag "Effective_Amount" effective_amount)
                    (symbol :tag "Support_Amount" support_amount)
                    (symbol :tag "Trending_Group" trending_group)
                    (symbol :tag "Trending_Mixed" trending_mixed)
                    (symbol :tag "Treading_Local" trending_local)
                    (symbol :tag "Trending_Global" trending_global)
                    (symbol :tag "Activation_Height" activation_height))
  :group 'elbry)

(defcustom elbry-download-directory "~/Downloads/"
  "Directory where files will downloaded.
This should either be a directory name."
  :group 'elbry
  :type 'directory)

(defcustom elbry-open-function #'elbry-open-default
  "Function to call with `elbry-open'"
  :group 'elbry
  :type 'function)

(defcustom elbry-file-type-icon-alist '(("audio" . "🎼")
                                       ("binary" . "🗋")
                                       ("document" . "🖺")
                                       ("image" . "🖼")
                                       ("video" . "🎥"))
  "An alist of icons associated with their file type."
  :group 'elbry
  :type '(alist :key-value string :value-type (string :tag "Icon"))
  :options '("audio" "binary" "document" "image" "video"))

(defcustom elbry-extension-icon-alist '(("pdf" . "📚")
                                 ("djvu" . "📚")
                                 ("epub" . "📚"))
  "An alist of icons associated with their extension."
  :group 'elbry
  :type '(alist :key-value string :value-type (string :tag "Icon")))

;;;; Internal variables

(defvar-local elbry-entries '(()))

(defvar elbry-api-url "http://localhost:5279"
  "Url to a ELBRY api.")

(defvar elbry-instance-url "https://odysee.com"
  "Url to a ELBRY host instance")

(defvar-local elbry-current-page 1
  "Current page of the current `elbry-search-term'")

(defvar-local elbry-search-term ""
  "Current search string as used by `elbry-search'")


(defvar elbry-search-message-prefix "Search terms: ")

(defvar elbry-search-message "")

(defvar elbry-title-reserved-space 65
  "Number of characters reserved for the video title in the *ELBRY* buffer.")

(defvar elbry-mode-map
  (let ((map (make-sparse-keymap)))
    (suppress-keymap map)
    (define-key map "q" #'quit-window)
    (define-key map "g" #'elbry-draw-buffer)
    (define-key map "h" #'desribe-mode)
    (define-key map "f" #'elbry-next-page)
    (define-key map "b" #'elbry-last-page)
    (define-key map "n" #'next-line)
    (define-key map "p" #'previous-line)
    (define-key map "s" #'elbry-search)
    (define-key map "d" #'elbry-download)
    (define-key map "o" #'elbry-open)
    (define-key map [return] #'elbry-open)
    map))

(defvar elbry-search-mode-map
  (let ((i 0)
	(map (make-keymap)))
    ;; (or (char-table-p (nth 1 map))
    ;; 	(error "The initialization of elbry-search-mode-map must be updated"))
    ;; (set-char-table-range (nth 1 map) (cons #x100 (max-char))
    ;; 			  'elbry-search-print-char)
    (setq i ?\s)
    (while (< i 256)
      (define-key map (vector i) 'elbry-search-print-char)
      (setq i (1+ i)))
    (let ((meta-map (make-sparse-keymap)))
      (define-key map (char-to-string meta-prefix-char) meta-map))
    (define-key map "\177" 'elbry-search-delete-char)
;;    (define-key map "\C-g" 'elbry-search-abort)
    (define-key map [return] 'elbry-search-exit)
    map)
  "Keymap for `elbry-search-mode'.")

;;;; Functions
;; Many thanks to @Malabarba for this fantastic solution
;; https://emacs.stackexchange.com/questions/3197/best-way-to-retrieve-values-in-nested-assoc-lists
(defun assoc-recursive (alist &rest keys)
  "Recursively find KEYs in ALIST."
  (while keys
            (setq alist (cdr (assoc (pop keys) alist))))
  alist)

(defun elbry-mime-sans-extension (mime)
  ""
  (save-match-data
    (string-match "/.*" mime)
    (substring mime 0 (match-beginning 0))))

(defun elbry-mime-extension (mime)
  ""
  (save-match-data
    (string-match "/.*" mime)
    (substring mime (1+ (match-beginning 0)))))

;;;;; Format JSON from `elbry-sdk'

(defun elbry--api-call (method args)
  (request "http://localhost:5279"
    :type "POST"
    :data (json-encode `(("method" . ,method)
                         ("params" . ,args)))
    :parser 'json-read
    :success
    (cl-function
     (lambda (&key claims &allow-other-keys)
       (let ((the-error (assoc-recursive claims 'error)))
         (if the-error
             (error "%s" (assoc-recursive claims 'error 'message))
           claims))))))

(defun elbry--query (string &optional page)
  "Query the ELBRY blockchain via `elbry-sdk' for STRING, return Nth page of resutls."
  (let ((claims (elbry--api-call "claim_search" `(("text" . ,string)
						 ("page" . ,(or page
							        elbry-current-page))
                                                 ("page_size" . ,(- (frame-height) 3))
						 ("claim_type" . "stream")
						 ("fee_amount" . 0)))))
    ;; Assigning value to `elbry-entry' struct
    (dotimes (i (assoc-recursive claims 'result 'page_size))
      (let ((stream (aref (assoc-recursive claims 'result 'items) i)))
	(aset (assoc-recursive claims 'result 'items) i
	      (elbry-entry-create :id (assoc-recursive stream 'claim_id)
				 :elbry-url (assoc-recursive stream 'permanent_url)
				 :release-time (assoc-recursive stream 'value 'release_time)
				 :file-date (assoc-recursive stream 'timestamp)
				 :title (or (assoc-recursive stream 'value 'title)
					    "No title available.")
				 :stream-type (assoc-recursive stream 'value 'stream_type)
				 :media-type (assoc-recursive stream 'value 'source 'media_type)
				 ;; Sometimes claims was published from an anonymous source
				 :channel (or (assoc-recursive stream 'signing_channel 'name)
					      "Anonymous")
				 :tags (assoc-recursive stream 'value 'tags)
				 :desc (assoc-recursive stream 'value 'description)))))
    (assoc-recursive claims 'result 'items)))
;;;;; Inserting *ELBRY* Buffer

(defun elbry--format-title (title &optional file-type)
  "Format a claim `TITLE' to be inserted according to `elbry-title-reserved-space'"
  (propertize title 'face (intern (format "elbry-%s-title" file-type))))

(defun elbry--format-time (timestamp)
  (let ((formatted-date (format-time-string "%Y-%m-%d" (if (stringp timestamp)
				     (string-to-number timestamp)
				     timestamp))))
    (propertize formatted-date 'face 'elbry-date)))

(defun elbry--format-duration (seconds)
  "Format `SECONDS' to \"hh:mm:ss\""
  (let ((formatted-string (concat (format-seconds "%.2h" seconds)
				  ":"
				  (format-seconds "%.2m" (mod seconds 3600))
				  ":"
				  (format-seconds "%.2s" (mod seconds 60)))))
    (propertize formatted-string 'face 'elbry-date)))

(defun elbry--format-file-type (media-type stream-type)
  (concat
   (propertize
    (or
     (alist-get (elbry-mime-extension media-type)
                elbry-extension-icon-alist nil nil #'string=)
     (alist-get stream-type
                elbry-file-type-icon-alist "" nil #'string=))
    'face
    (intern (format "elbry-%s-title" stream-type)))
   (propertize
    stream-type
    'face
    (intern (format "elbry-%s-title" stream-type)))
   "/"
   (propertize (elbry-mime-extension media-type) 'face 'elbry-tags)))

(defun elbry--format-tags (tags)
  (propertize 
  (cond ((stringp tags) tags)
	 ((arrayp tags) (mapconcat (lambda (element-in-array) (format "%s" element-in-array)) tags ",")))
  'face 'elbry-tags))

(defun elbry--format-channel (name)
  "Propertize `NAME' for *ELBRY* buffer"
  (propertize name 'face 'elbry-channel))

(defun elbry--insert-entry (claim)
  "Insert `CLAIM' in the current buffer."
  (list (elbry-entry-id claim)
        (vector (elbry--format-time (if (string-match-p "video" (elbry-entry-media-type claim))
                                       (elbry-entry-release-time claim)
                                     (elbry-entry-file-date claim)))
                (elbry--format-title (elbry-entry-title claim) (elbry-entry-stream-type claim))
                (elbry--format-channel (elbry-entry-channel claim))
                (elbry--format-file-type (elbry-entry-media-type claim) (elbry-entry-stream-type claim)))))

(defun elbry-draw-buffer ()
  (interactive)
  (let ((inhibit-read-only t))
    (erase-buffer)
    (setq tabulated-list-format `[("Date" 10 t)
                                  ("Title" 65 t)
                                  ("Channel" 20 t)
                                  ("Type" 15 t)])
    (setq tabulated-list-entries (mapcar #'elbry--insert-entry
                                         elbry-entry))
    (setq tabulated-list--header-string
          (concat "Search results for "
        	  elbry-search-term ", page "
        	  (number-to-string elbry-current-page)))
    (tabulated-list-init-header)
    (tabulated-list-print)))

;;;###autoload
(defun elbry-download (&optional entry tmp)
  "Apply `elbry-*-function' depending on the media type of `ENTRY'"
  (interactive)
  (let* ((entry (or entry (elbry-get-current-claim)))
         (claim-url (elbry-entry-elbry-url entry))
         (file-json (elbry--api-call "get" (list (cons "uri" claim-url)
				                (cons "download_directory"
                                                      (if tmp
                                                          temporary-file-directory
                                                        (expand-file-name
                                                         elbry-download-directory)))))))
    (assoc-recursive file-json 'result 'download_path)))

(defun elbry-open-default (&optional entry)
  "Invoke `elbry-open-*' function for the media type of `ENTRY' with its url as the argument.

If the function doesn't exist, fallback to `elbry-download' the entry
then call `xdg-open' to open it."
  (interactive)
  (let* ((entry (or entry (elbry-get-current-claim)))
	 (url (elbry-entry-elbry-url entry))
         (type-fn (intern (concat "elbry-open-" (elbry-mime-sans-extension (elbry-entry-media-type entry))))))
    (if (fboundp type-fn)
        (funcall type-fn url)
      (start-process "elbry-open" nil "xdg-open" (elbry-download)))))

(defun elbry-open (&optional entry)
  "Use `elbry-open-function' to open this `ENTRY'."
  (interactive)
  (funcall (or elbry-open-function #'elbry-open-default-function) (elbry-get-current-claim)))

;;;;; *ELBRY* buffer
;;;;;; Navigation functions
(defun elbry-next-page ()
  "Switch to the next page of the current search. Redraw the buffer."
  (interactive)
  (setf elbry-entry (elbry--query elbry-search-term (1+ elbry-current-page)))
  (setf elbry-current-page (1+ elbry-current-page))
  (elbry-draw-buffer))

(defun elbry-last-page ()
  "Switch to the last page of the current search. Redraw the buffer."
  (interactive)
  (when (> elbry-current-page 1)
    (setf elbry-entry (elbry--query elbry-search-term (1- elbry-current-page)))
    (setf elbry-current-page (1- elbry-current-page))
    (elbry-draw-buffer)))
;;;;; elbry-search minor mode

(define-minor-mode elbry-search-mode
  "Mode for searching in *ELBRY* buffer."
  :init-value nil
  :lighter " Search"
  :keymap 
  (make-local-variable 'elbry-search-term)
  (setq elbry-current-page 1)
  (elbry-search-update))

(defun elbry-search () 
   (interactive)
   (elbry-search-mode 1))

(defun elbry-search-exit ()
  (interactive)
  (elbry-search-mode 0)
  (force-mode-line-update)
  (setf elbry-current-page 1)
  (setq elbry-search-term elbry-search-message)
  (setq elbry-search-message "")
  (setf elbry-entry (elbry--query elbry-search-term))
  (elbry-draw-buffer))

;;;;;; Building search message

(defun elbry-search-prefix ()
  (propertize elbry-search-message-prefix 'face 'minibuffer-prompt 'read-only 't))

(defun elbry-search-update ()
  (if (not (input-pending-p))
      (funcall #'elbry-search-message)))

(defun elbry-search-print-char (&optional char count)
  (interactive (list last-command-event
		     (prefix-numeric-value current-prefix-arg)))
  (let ((char (or char last-command-event)))
    (if (= char ?\S-\ )
	(setq char ?\s))
    (elbry-process-search-char char count)))

(defun elbry-process-search-char (char &optional count)
  (let* ((string (if (and (integerp count) (> count 1))
		     (make-string count char)
		   (char-to-string char)))
	 (message (if (>= char ?\200)
		      string
		    (mapconcat 'char-to-string string ""))))
    (elbry-process-search-string string message)))

(defun elbry-process-search-string (string message)
  (setq elbry-search-term (concat elbry-search-term string)
	elbry-search-message (concat elbry-search-message message))
  (elbry-search-update))

(defun elbry-search-message ()
  (let ((m elbry-search-message))
    (when (string-match "@.* \\| @.*$" elbry-search-message)
      (setq m (copy-sequence m))
      (add-text-properties (match-beginning 0) (match-end 0) '(face elbry-channel) m))
    (setq m (concat
	     (elbry-search-prefix)
	     m))
    (let ((message-log-max nil))
      (message "%s" m))))

(defun elbry-search-delete-char ()
  (interactive)
  (if (seq-empty-p elbry-search-term)
      (ding)
    (setf elbry-search-message (substring elbry-search-message 0 (1- (length elbry-search-message)))))
  (elbry-search-update))
    
(defun elbry-get-current-claim ()
  (aref elbry-entry (1- (line-number-at-pos))))

(define-derived-mode elbry-mode tabulated-list-mode "ELBRY"
  (setq buffer-read-only t
	truncate-line t)
  (buffer-disable-undo)
  (hl-line-mode))

;;;###autoload
(defun elbry ()
  (interactive)
  (switch-to-buffer (get-buffer-create "*ELBRY*"))
  (unless (eq major-mode 'elbry-mode)
    (elbry-mode))
  (when (= elbry-search-term "")
   (call-interactively #'elbry-search)))

(provide 'elbry-mode)
;;; elbry-mode.el ends here
